Signa
l 是建立儲存和管理 states 的好工具。對於一個簡單的應用程序,我們可以使用 Angular Signal 建立 store
來管理全域資料 (global data) 或本地資料 (local data)。 當應用程式擴充時,開發人員應考慮開源程式庫,例如 NGRX
、NGRX Signal Store
、NGXS
、TanStack Store
等。
在這篇文章中,我使用 signal 建立一個 store 來追蹤我的收入和支出,計算總計和差異。
import { computed, Injectable, signal } from "@angular/core";
import { AccountRecords, InputItem } from "../types/account.type";
import { ItemType } from "../enums/account.enum";
@Injectable({
providedIn: 'root'
})
export class AccountStore {
#state = signal<AccountRecords>({
incomes: [],
expenses: [],
});
#totalIncomes = computed(() => this.#state().incomes.reduce((acc, item) =>
acc + item.amount, 0));
#totalExpenses = computed(() => this.#state().expenses.reduce((acc, item) => acc + item.amount, 0));
summary = computed(() => ({
incomes: this.#state().incomes,
expenses: this.#state().expenses,
totalIncomes: this.#totalIncomes(),
totalExpenses: this.#totalExpenses(),
hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
surplus: this.#totalIncomes() - this.#totalExpenses()
}));
addItem({ type, date, amount, description }: InputItem) {
const newItem = { date, amount, description };
if (type === ItemType.INCOME) {
this.#state.update((value) => ({
incomes: [...value.incomes, newItem],
expenses: value.expenses,
}));
} else if (type === ItemType.EXPENSE) {
this.#state.update((value) => ({
incomes: value.incomes,
expenses: [...value.expenses, newItem],
}));
}
}
}
AccountStore 服務 (service) 有一個私有變數 #state
,用於將收入和支出記錄儲存在 signal 中。 該服務還具有多個計算訊號 (computed signal) 以從中獲取值。 #totalIncomes
計算收入總額,而#totalExpenses
計算支出總額。 #summary
computed signal 回傳 incomes
、 expenses
、 totalIncomes
、totalExpenses
、surplus
和 hasMoneyLeft
。這是因為例子中的組件存取#summary
的屬性來顯示值。 store 公開 addItem
方法來更新 #state
,而不是直接操作它。
@Component({
selector: 'app-account-form',
standalone: true,
imports: [ReactiveFormsModule, FormsModule],
template: `
<form [formGroup]="form" style="margin-bottom: 1rem;" (ngSubmit)="formSubmitSub.next()">
<div>
<label for="date"><span>Date: </span></label>
<input type="date" id="date" name="date" formControlName="date" />
</div>
<div>
<label for="type"><span>Type: </span></label>
<select id="type" name="type" formControlName="type">
<option value="Income">Income</option>
<option value="Expense">Expense</option>
</select>
</div>
<div>
<label for="description"><span>Description: </span></label>
<input id="description" name="description" formControlName="description" />
</div>
<div>
<label for="amount"><span>Amount: </span></label>
<input type="number" id="amount" name="amount" formControlName="amount" />
</div>
<button type="submit" [disabled]="this.form.invalid">Add an item</button>
</form>
`,
})
export default class AppAccountFormComponent {
form = new FormGroup({
type: new FormControl(ItemType.INCOME, { nonNullable: true, validators: [Validators.required] }),
amount: new FormControl(0, { nonNullable: true, validators: [ Validators.required, Validators.min(0)] }),
description: new FormControl('', { nonNullable: true, validators: [ Validators.required ]}),
date: new FormControl(this.getCurrentDate(), { nonNullable: true, validators: [ Validators.required ]})
});
formSubmitSub = new Subject<void>();
private getCurrentDate() {
const today = new Date();
const year = today.getFullYear();
const month = (today.getMonth() + 1).toString().padStart(2, '0');
const day = today.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
submittedValues = outputFromObservable(this.form.events.pipe(
filter((e) => e instanceof FormSubmittedEvent),
filter((e) => e.source.valid),
map(({ source }) => source.value),
));
}
AppAccountFormComponent
組件建構一個 reactive 表單,供使用者輸入收入或支出記錄。當使用者提交表單時, submittedValues
output 會將表單資料傳送到父組件。
import { Component, ChangeDetectionStrategy, input, HostAttributeToken, inject } from '@angular/core';
import { Item } from './types/account.type';
@Component({
selector: 'app-account-list',
standalone: true,
template: `
<h3 style="text-align: center;">{{ title }}</h3>
@if (items().length) {
<ol>
@for (item of items(); track item) {
<li>
<p>Date: {{ item.date }}</p>
<p>Description: {{ item.description }}</p>
<p>Amount: {{ item.amount }}</p>
</li>
}
</ol>
} @else {
<p>No item.</p>
}
`,
})
export default class AppAccountListComponent {
items = input.required<Item[]>();
title = inject(new HostAttributeToken('title'), { optional: true }) || 'Title';
}
AppAccountListComponent
組件從 store 接收收入或支出,並迭代陣列以顯示每筆記錄。
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
@Component({
selector: 'app-account-summary',
standalone: true,
template: `
@let moneyLeft = summary().surplus;
@let text = moneyLeft ? 'Surplus' : 'Deficit';
<div>
<p>Total income: {{ summary().totalIncomes }}</p>
<p>Total expenses: {{ summary().totalExpenses }}</p>
<p>{{ text }}: {{ moneyLeft }}
<p>Has money left? {{ summary().hasMoneyLeft }}</p>
</div>
`,
})
export default class AppAccountSummaryComponent {
summary = input.required<{
totalIncomes: number;
totalExpenses: number;
hasMoneyLeft: boolean;
surplus: number;
}>();
}
AppAccountSummaryComponent
組件接收來自 store 的 summary,並顯示總數、差異以及是否還有錢可以花。
@Component({
selector: 'app-account-wrapper',
standalone: true,
imports: [AppAccountFormComponent, AppAccountListComponent, AppAccountSummaryComponent],
template: `
<div class="photo-output-wrapper">
<app-account-form class="form" />
<h2>Balance Sheet</h2>
<app-account-list title='Incomes' [items]="summary().incomes" />
<app-account-list title='Expenses' [items]="summary().expenses" />
<app-account-summary [summary]="summary()"
/>
</div>
`,
})
export default class AppAccountWrapperComponent {
acountForm = viewChild.required(AppAccountFormComponent);
store = inject(AccountStore);
summary = this.store.summary;
constructor() {
effect((OnCleanUp) => {
const sub = this.acountForm().submittedValues.subscribe((data) =>
this.store.addItem(data as InputItem));
OnCleanUp(() => sub.unsubscribe());
});
}
}
AppAccountWrapperComponent
注入 AccountStore
並將 summary
computed signal 指派給summary
變數。 effect
訂閱 submittedValues
並將表單資料新增至 account store。當 store 的 signal 更新時,所有 computed signals 都會重新計算新值。
@let summary = store.summary();
<app-account-list title='Incomes' [items]="summary.incomes" />
<app-account-list title='Expenses' [items]="summary.expenses" />
<app-account-summary [summary]="summary" />
HTML 範本使用 let-語法
(let-syntax) 來暫時儲存 summary
。然後,收入和支出傳遞到 AppAccountListComponent
組件的輸入。整個 summar
y 物件傳遞到 AppAccountSummaryComponent
組件以顯示總計、hasMoreMoney 標誌以及剩餘金額。
NGRX
、NGRX Signal Store
或其他函式庫。另一種方法是使用 injectionToken 來提供 store,我明天將討論。
鐵人賽的第 27 天到此結束。